<!DOCTYPE html>

<head>
<title>tinychat</title>
<meta content="width=device-width, initial-scale=1" name="viewport"/>
<link href="favicon.svg" rel="icon" type="image/svg+xml"/>
<script defer="" src="/static/cdn.jsdelivr.net/npm/@alpine-collective/toolkit@1.0.2/dist/cdn.min.js"></script>
<script defer="" src="/static/cdn.jsdelivr.net/npm/@alpinejs/intersect@3.x.x/dist/cdn.min.js"></script>
<script defer="" src="/static/cdn.jsdelivr.net/npm/@alpinejs/focus@3.x.x/dist/cdn.min.js"></script>
<script defer="" src="/static/unpkg.com/@marcreichel/alpine-autosize@1.3.x/dist/alpine-autosize.min.js"></script>
<script defer="" src="/static/unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="/static/unpkg.com/dompurify@3.1.5/dist/purify.min.js"></script>
<script src="/static/unpkg.com/marked@13.0.0/marked.min.js"></script>
<script src="/static/unpkg.com/marked-highlight@2.1.2/lib/index.umd.js"></script>
<script src="/static/unpkg.com/@highlightjs/cdn-assets@11.9.0/highlight.min.js"></script>
<script src="/index.js"></script>
<link href="/static/fonts.googleapis.com" rel="preconnect"/>
<link crossorigin="" href="/static/fonts.gstatic.com" rel="preconnect"/>
<link href="/static/fonts.googleapis.com/css2" rel="stylesheet"/>
<link href="/static/cdn.jsdelivr.net/npm/purecss@3.0.0/build/base-min.css" rel="stylesheet"/>
<link crossorigin="anonymous" href="/static/cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" referrerpolicy="no-referrer" rel="stylesheet">
<link href="/static/unpkg.com/@highlightjs/cdn-assets@11.9.0/styles/vs2015.min.css" rel="stylesheet"/>
<link href="/index.css" rel="stylesheet"/>
<link href="/common.css" rel="stylesheet"/>
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
</head>
<body>
<main x-data="state" x-init="console.log(endpoint)">
  <div class="sidebar">
    <!-- Add topology section -->
    <div class="topology-section">
      <h2 class="megrim-regular">Network Topology</h2>
      <div class="topology-visualization"
           x-init="initTopology()"
           x-ref="topologyViz">
        <!-- Loading indicator for topology -->
        <div class="topology-loading" x-show="!topology">
          <i class="fas fa-spinner fa-spin"></i>
          <span>Loading topology...</span>
        </div>
        <!-- Topology visualization will be rendered here -->
      </div>
    </div>

    <h2 class="megrim-regular" style="margin-bottom: 20px;">Models</h2>
      <div style="display: flex; align-items: center; margin-bottom: 10px;">
          <label style="margin-right: 5px;">
              <input type="checkbox" x-model="showDownloadedOnly" style="margin-right: 5px;">
              Downloaded only
          </label>
      </div>

    <!-- Loading indicator -->
    <div class="loading-container" x-show="Object.keys(models).length === 0">
        <i class="fas fa-spinner fa-spin"></i>
        <span>Loading models...</span>
    </div>

    <!-- Group models by prefix -->
    <template x-for="[mainPrefix, subGroups] in Object.entries(groupModelsByPrefix(models))" :key="mainPrefix">
        <div class="model-group">
            <div class="model-group-header" @click="toggleGroup(mainPrefix)">
                <div class="group-header-content">
                    <span x-text="mainPrefix"></span>
                    <span class="model-count" x-text="getGroupCounts(Object.values(subGroups).flatMap(group => Object.values(group)))"></span>
                </div>
                <i class="fas" :class="isGroupExpanded(mainPrefix) ? 'fa-chevron-down' : 'fa-chevron-right'"></i>
            </div>
            
            <div class="model-group-content" x-show="isGroupExpanded(mainPrefix)" x-transition>
                <template x-for="[subPrefix, groupModels] in Object.entries(subGroups)" :key="subPrefix">
                    <div class="model-subgroup">
                        <div class="model-subgroup-header" @click.stop="toggleGroup(mainPrefix, subPrefix)">
                            <div class="group-header-content">
                                <span x-text="subPrefix"></span>
                                <span class="model-count" x-text="getGroupCounts(groupModels)"></span>
                            </div>
                            <i class="fas" :class="isGroupExpanded(mainPrefix, subPrefix) ? 'fa-chevron-down' : 'fa-chevron-right'"></i>
                        </div>
                        
                        <div class="model-subgroup-content" x-show="isGroupExpanded(mainPrefix, subPrefix)" x-transition>
                            <template x-for="(model, key) in groupModels" :key="key">
                                <div class="model-option"
                                     :class="{ 'selected': cstate.selectedModel === key }"
                                     @click="cstate.selectedModel = key">
                                    <div class="model-header">
                                        <div class="model-name" x-text="model.name"></div>
                                        <button
                                            @click.stop="deleteModel(key, model)"
                                            class="model-delete-button"
                                            x-show="model.download_percentage > 0">
                                            <i class="fas fa-trash"></i>
                                        </button>
                                    </div>
                                    <div class="model-info">
                                        <div class="model-progress">
                                            <template x-if="model.loading">
                                                <span><i class="fas fa-spinner fa-spin"></i> Checking download status...</span>
                                            </template>
                                            <div class="model-progress-info">
                                                <template x-if="!model.loading && model.download_percentage != null">
                                                    <span>
                                                        <template x-if="downloadProgress?.some(p =>
                                                            p.repo_id && p.repo_id.toLowerCase().includes(key.toLowerCase()) && !p.isComplete
                                                        )">
                                                            <i class="fas fa-circle-notch fa-spin"></i>
                                                        </template>
                                                        <span x-text="model.downloaded ? 'Downloaded' : `${Math.round(model.download_percentage)}% downloaded`"></span>
                                                    </span>
                                                </template>
                                                <template x-if="!model.loading && (model.download_percentage === null || model.download_percentage < 100) && !downloadProgress?.some(p => !p.isComplete)">
                                                    <button
                                                        @click.stop="handleDownload(key)"
                                                        class="model-download-button">
                                                        <i class="fas fa-download"></i>
                                                        <span x-text="(model.download_percentage > 0 && model.download_percentage < 100) ? 'Continue Downloading' : 'Download'"></span>
                                                    </button>
                                                </template>
                                            </div>
                                        </div>
                                        <template x-if="model.total_size">
                                            <div class="model-size" x-text="model.total_downloaded ?
                                                `${formatBytes(model.total_downloaded)} / ${formatBytes(model.total_size)}` :
                                                formatBytes(model.total_size)">
                                            </div>
                                        </template>
                                    </div>
                                </div>
                            </template>
                        </div>
                    </div>
                </template>
            </div>
        </div>
    </template>
  </div>
    <!-- Error Toast -->
    <div x-show="errorMessage !== null" x-transition.opacity class="toast">
        <div class="toast-header">
            <span class="toast-error-message" x-text="errorMessage?.basic || ''"></span>
            <div class="toast-header-buttons">
                <button @click="errorExpanded = !errorExpanded; if (errorTimeout) { clearTimeout(errorTimeout); errorTimeout = null; }"
                        class="toast-expand-button"
                        x-show="errorMessage?.stack">
                    <span x-text="errorExpanded ? 'Hide Details' : 'Show Details'"></span>
                </button>
                <button @click="errorMessage = null; errorExpanded = false;" class="toast-close-button">
                    <i class="fas fa-times"></i>
                </button>
            </div>
        </div>
        <div class="toast-content" x-show="errorExpanded" x-transition>
            <span x-text="errorMessage?.stack || ''"></span>
        </div>
    </div>

<div @popstate.window="
      if (home === 2) {
        home = -1;
        cstate = { time: null, messages: [], selectedModel: 'llama-3.1-8b' };
        time_till_first = 0;
        tokens_per_second = 0;
        total_tokens = 0;
      }
    " class="home centered" x-effect="
      $refs.inputForm.focus();
      if (home === 1) setTimeout(() =&gt; home = 2, 100);
      if (home === -1) setTimeout(() =&gt; home = 0, 100);
    " x-show="home === 0" x-transition="">
<h1 class="title megrim-regular">tinychat</h1>
<template x-if="histories.length">
  <button
    @click="if(confirm('Are you sure you want to clear all history?')) clearAllHistory();"
    class="clear-history-button">
    <i class="fas fa-trash"></i> Clear All History
  </button>
</template>
<div class="histories-container-container">
<template x-if="histories.length">
<div class="histories-start"></div>
</template>
<div class="histories-container" x-intersect="
          $el.scrollTo({ top: 0, behavior: 'smooth' });
        ">
<template x-for="_state in histories.toSorted((a, b) =&gt; b.time - a.time)">
<div @click="
            cstate = _state;
            if (!cstate.selectedModel) cstate.selectedModel = 'llama-3.2-1b';
            home = 2;
            window.history.pushState({}, '', '/');
          " @touchend="
            if (Math.abs($event.changedTouches[0].clientX - otx) &gt; trigger) removeHistory(_state);
            $el.style.setProperty('--tx', 0);
            $el.style.setProperty('--opacity', 1);
          " @touchmove="
            $el.style.setProperty('--tx', $event.changedTouches[0].clientX - otx);
            $el.style.setProperty('--opacity', 1 - (Math.abs($event.changedTouches[0].clientX - otx) / trigger));
          " @touchstart="
            otx = $event.changedTouches[0].clientX;
          " class="history" x-data="{ otx: 0, trigger: 75 }">
<h3 x-text="new Date(_state.time).toLocaleString()"></h3>
<p x-text="$truncate(_state.messages[0].content, 76)"></p>
<!-- delete button -->
<button @click.stop="removeHistory(_state);" class="history-delete-button">
<i class="fas fa-trash"></i>
</button>
</div>
</template>
</div>
<template x-if="histories.length">
<div class="histories-end"></div>
</template>
</div>
</div>
</div>
<button
    @click="
        home = 0;
        cstate = { time: null, messages: [], selectedModel: cstate.selectedModel };
        time_till_first = 0;
        tokens_per_second = 0;
        total_tokens = 0;
    "
    class="back-button"
    x-show="home === 2">
    <i class="fas fa-arrow-left"></i>
    Back to Chats
</button>
<div class="messages"
  x-init="
    $watch('cstate', (value) => {
      $el.innerHTML = '';

      value.messages.forEach((msg) => {
        const div = document.createElement('div');
        div.className = `message message-role-${msg.role}`;

        try {
          // If there's an embedded generated image
          if (msg.content.includes('![Generated Image]')) {
            const imageUrlMatch = msg.content.match(/\((.*?)\)/);
            if (imageUrlMatch) {
              const imageUrl = imageUrlMatch[1];
              const img = document.createElement('img');
              img.src = imageUrl;
              img.alt = 'Generated Image';

              img.onclick = async () => {
                try {
                  const response = await fetch(img.src);
                  const blob = await response.blob();
                  const file = new File([blob], 'image.png', { type: 'image/png' });
                  handleImageUpload({ target: { files: [file] } });
                } catch (error) {
                  console.error('Error fetching image:', error);
                }
              };
              div.appendChild(img);
            } else {
              // fallback if markdown is malformed
              div.textContent = msg.content;
            }
          } else {
            // Otherwise, transform message text (including streamed think blocks).
            div.innerHTML = transformMessageContent(msg);
            // Render math after content is inserted
            MathJax.typesetPromise([div]);
          }
        } catch (e) {
          console.error('Error rendering message:', e);
          div.textContent = msg.content; // fallback
        }

        // Add a clipboard button to code blocks
        const codeBlocks = div.querySelectorAll('.hljs');
        codeBlocks.forEach((codeBlock) => {
          const button = document.createElement('button');
          button.className = 'clipboard-button';
          button.innerHTML = '<i class=\'fas fa-clipboard\'></i>';

          button.onclick = () => {
            const range = document.createRange();
            range.setStartBefore(codeBlock);
            range.setEndAfter(codeBlock);
            window.getSelection()?.removeAllRanges();
            window.getSelection()?.addRange(range);
            document.execCommand('copy');
            window.getSelection()?.removeAllRanges();

            button.innerHTML = '<i class=\'fas fa-check\'></i>';
            setTimeout(() => {
              button.innerHTML = '<i class=\'fas fa-clipboard\'></i>';
            }, 1000);
          };

          codeBlock.appendChild(button);
        });

        $el.appendChild(div);
      });

      // Scroll to bottom after rendering
      $el.scrollTo({ top: $el.scrollHeight, behavior: 'smooth' });
    });
  "
  x-ref="messages"
  x-show="home === 2"
  x-transition=""
>
</div>

<!-- Download Progress Section -->
<template x-if="downloadProgress && downloadProgress.length > 0">
  <div class="download-progress message message-role-assistant">
    <h2>Download Progress</h2>
    <br>
    <template x-for="(progress, index) in downloadProgress" :key="index">
      <div class="download-progress-node">
        <br>
        <h3 x-text="`Download ${index + 1}`"></h3>
        <p><strong>Model:</strong> <span x-text="progress.repo_id + '@' + progress.repo_revision"></span></p>
        <p><strong>Status:</strong> <span x-text="progress.status"></span></p>
        <div class="progress-bar-container">
          <div class="progress-bar"
               :class="progress.isComplete ? 'complete' : 'in-progress'"
               :style="`width: ${progress.percentage}%;`">
          </div>
        </div>
        <template x-if="!progress.isComplete">
          <div>
            <p><strong>Progress:</strong> <span x-text="`${progress.downloaded_bytes_display} / ${progress.total_bytes_display} (${progress.percentage}%)`"></span></p>
            <p><strong>Speed:</strong> <span x-text="progress.overall_speed_display || 'N/A'"></span></p>
            <p><strong>ETA:</strong> <span x-text="progress.overall_eta_display || 'N/A'"></span></p>
          </div>
        </template>
      </div>
    </template>
  </div>
</template>


<div class="input-container">
<div class="input-performance">
<span class="input-performance-point">
<p class="monospace" x-text="(time_till_first / 1000).toFixed(2)"></p>
<p class="megrim-regular">SEC TO FIRST TOKEN</p>
</span>
<span class="input-performance-point">
<p class="monospace" x-text="tokens_per_second.toFixed(1)"></p>
<p class="megrim-regular">TOKENS/SEC</p>
</span>
<span class="input-performance-point">
<p class="monospace" x-text="total_tokens"></p>
<p class="megrim-regular">TOKENS</p>
</span>
</div>
<div class="input">
<button @click="$refs.imageUpload.click()" class="image-input-button" x-show="cstate.selectedModel === 'llava-1.5-7b-hf' || cstate.selectedModel === 'stable-diffusion-2-1-base'">
<i class="fas fa-image"></i>
</button>
<input @change="$data.handleImageUpload($event)" accept="image/*" id="image-upload" style="display: none;" type="file" x-ref="imageUpload"/>
<div class="image-preview-container" x-show="imagePreview">
<img :src="imagePreview" alt="Uploaded Image" class="image-preview"/>
<button @click="imagePreview = null; imageUrl = null;" class="remove-image-button">
<i class="fas fa-times"></i>
</button>
</div>
<textarea
    :disabled="generating || (downloadProgress?.length > 0 && downloadProgress.some(p => !p.isComplete))"
    :placeholder="
        generating ? 'Generating...' :
        (downloadProgress?.length > 0 && downloadProgress.some(p => !p.isComplete)) ? 'Download in progress...' :
        'Say something'
    "
    @input="
        home = (home === 0) ? 1 : home
        if (cstate.messages.length === 0 && $el.value === '') home = -1;

        if ($el.value !== '') {
            const messages = [...cstate.messages];
            messages.push({ role: 'user', content: $el.value });
            // updateTotalTokens(messages);
        } else {
            if (cstate.messages.length === 0) total_tokens = 0;
            // else updateTotalTokens(cstate.messages);
        }
    "
    @keydown.enter="await handleEnter($event)"
    @keydown.escape.window="$focus.focus($el)"
    autofocus=""
    class="input-form"
    id="input-form"
    rows="1"
    x-autosize=""
    x-effect="
        console.log(generating);
        if (!generating) $nextTick(() => {
            $el.focus();
            setTimeout(() => $refs.messages.scrollTo({ top: $refs.messages.scrollHeight, behavior: 'smooth' }), 100);
        });
    "
    x-ref="inputForm"></textarea>
<button
    :disabled="generating || (downloadProgress?.length > 0 && downloadProgress.some(p => !p.isComplete))"
    @click="await handleSend()"
    class="input-button">
    <i :class="generating ? 'fa-spinner fa-spin' : 'fa-paper-plane'" class="fas"></i>
</button>
</div>
</div>
</main>

<script>
  /**
   * Transform a single message's content into HTML, preserving <think> blocks.
   * Ensure LaTeX expressions are properly delimited for MathJax.
   */
  function transformMessageContent(message) {
    let text = message.content;
    console.log('Processing message content:', text);

    // First replace think blocks
    text = text.replace(
      /<think>([\s\S]*?)(?:<\/think>|$)/g,
      (match, body) => {
        console.log('Found think block with content:', body);
        const isComplete = match.includes('</think>');
        const spinnerClass = isComplete ? '' : ' thinking';
        const parsedBody = DOMPurify.sanitize(marked.parse(body));
        return `
<div class='thinking-block'>
  <div class='thinking-header${spinnerClass}'>Thinking...</div>
  <div class='thinking-content'>${parsedBody}</div>
</div>`;
      }
    );

    // Add backslashes to parentheses and brackets for LaTeX
    text = text
      .replace(/\((?=\s*[\d\\])/g, '\\(')  // Add backslash before opening parentheses
      .replace(/\)(?!\w)/g, '\\)')          // Add backslash before closing parentheses
      .replace(/\[(?=\s*[\d\\])/g, '\\[')   // Add backslash before opening brackets
      .replace(/\](?!\w)/g, '\\]')          // Add backslash before closing brackets
      .replace(/\[[\s\n]*\\boxed/g, '\\[\\boxed') // Ensure boxed expressions are properly delimited
      .replace(/\\!/g, '\\\\!');  // Preserve LaTeX spacing commands

    return DOMPurify.sanitize(marked.parse(text));
  }
</script>
</body>
